All files / web/src/app/api/abacus/designs/[id]/share route.ts

88.75% Statements 71/80
71.42% Branches 15/21
100% Functions 2/2
88.75% Lines 71/80

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 811x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 16x 1x 1x 3x 3x 3x 3x 1x 3x       3x 1x 1x 14x 14x 14x 14x 14x 11x 11x 11x 11x 11x 11x 11x 11x 11x 11x 14x       14x 1x 1x 5x 5x 5x 5x 3x 3x 3x 3x 3x 3x 5x       5x  
/**
 * Design sharing (Gitea #24) — the ACCESS sub-resource of an immutable design.
 *
 *   GET    → { shared, sharedAt }   owner-or-admin
 *   POST   → share    (idempotent: an already-shared design keeps its first
 *                      sharedAt — a re-share records nothing new)
 *   DELETE → un-share (sharedAt = NULL; the SAME id can be re-shared to the
 *                      SAME url, which is what makes the toggle its own undo)
 *
 * Every denial — not yours, unknown id — answers the byte-identical 404 the
 * design read gives, so a stranger can never probe which designs exist or
 * which are shared. Guest-first like the rest of the studio (a guest can save
 * and print, so a guest can share); because the flag rides the design row,
 * mergeGuestIntoUser's existing reparent carries the share on sign-in.
 *
 * Deliberately NOT folded into GET /api/abacus/designs/[id]: that response
 * feeds a staleTime-Infinity cache of immutable content, and share state is
 * mutable. Two resources, two lifetimes.
 */
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { manageableDesign as manageable } from '@/lib/abacus/design-access'
import { withAuth } from '@/lib/auth/withAuth'
 
const notFound = () => NextResponse.json({ error: 'Design not found' }, { status: 404 })
 
const state = (sharedAt: Date | null) =>
  NextResponse.json({ shared: sharedAt !== null, sharedAt: sharedAt ? sharedAt.getTime() : null })
 
export const GET = withAuth(async (_request, { userRole, params }) => {
  try {
    const { id } = await params
    const row = await manageable(typeof id === 'string' ? id : '', userRole)
    if (!row) return notFound()
    return state(row.sharedAt)
  } catch (error) {
    console.error('[abacus-designs] share read failed:', error)
    return NextResponse.json({ error: 'Failed to load sharing' }, { status: 500 })
  }
})
 
export const POST = withAuth(async (_request, { userRole, params }) => {
  try {
    const { id } = await params
    const row = await manageable(typeof id === 'string' ? id : '', userRole)
    if (!row) return notFound()
    if (row.sharedAt !== null) return state(row.sharedAt) // already shared — nothing to record
 
    // Drizzle's timestamp mode persists whole seconds, so truncate at the
    // source: the value this response reports is then byte-for-byte the value
    // a later read gives back, and "already shared" is genuinely idempotent.
    const sharedAt = new Date(Math.floor(Date.now() / 1000) * 1000)
    await db
      .update(schema.abacusDesigns)
      .set({ sharedAt })
      .where(eq(schema.abacusDesigns.id, row.id))
    return state(sharedAt)
  } catch (error) {
    console.error('[abacus-designs] share failed:', error)
    return NextResponse.json({ error: 'Failed to share design' }, { status: 500 })
  }
})
 
export const DELETE = withAuth(async (_request, { userRole, params }) => {
  try {
    const { id } = await params
    const row = await manageable(typeof id === 'string' ? id : '', userRole)
    if (!row) return notFound()
 
    await db
      .update(schema.abacusDesigns)
      .set({ sharedAt: null })
      .where(eq(schema.abacusDesigns.id, row.id))
    return state(null)
  } catch (error) {
    console.error('[abacus-designs] un-share failed:', error)
    return NextResponse.json({ error: 'Failed to un-share design' }, { status: 500 })
  }
})